Screen Structure and Wiring

This document explains how a feature screen is structured in Baselines and how its pieces are wired together. Each component has a single responsibility and a clear place in the flow from user interaction to UI rendering.

The goal is:

  • Predictable screen structure
  • Explicit data flow
  • Minimal recompositions
  • Easy testability and maintenance

Overview: How a Screen Is Composed

A feature screen is built from the following building blocks:

  1. UiEvent — user intent
  2. UiState — single source of truth for rendering
  3. ViewModel — UI logic and state producer
  4. Screen — pure UI
  5. Route — wiring layer
  6. UiModule — dependency injection and navigation registration

Each layer exists to separate concerns and keep the UI stable as the feature grows.


1. UiEvent — Capturing User Actions

Responsibility: Represent everything the user can do on the screen.

UiEvent is a sealed type that enumerates all user interactions such as clicks, gestures, or selections.

kotlin
1sealed interface ProfileUiEvent : UiEvent {
2 data object PerformLogout : ProfileUiEvent {
3 override val dispatchPolicy = UiEventDispatchPolicy.ThrottleFirst()
4 }
5}

Guidelines:

  • Model intent, not UI mechanics.
  • Start minimal and grow as the feature evolves.
  • Override dispatchPolicy only when the event needs non-default dispatch behavior.
  • Use ThrottleFirst for one-shot tap actions and DebounceLatest for latest-value effects like search.
  • For controlled inputs such as text fields, keep the visible input state immediate and debounce only the delayed side effect.

2. UiState — Single Source of Truth

Responsibility: Hold all data required to render the screen.

The UI reads from UiState only. There is no implicit state hidden in the composables.

kotlin
1@Immutable
2data class ProfileUiState(
3 val sections: ImmutableList<Section>,
4 override val eventSink: (ProfileUiEvent) -> Unit,
5) : UiState<ProfileUiEvent>

Guidelines:

  • Annotate with @Immutable to reduce recompositions.
  • Keep state explicit.
  • If the state grows, split it into smaller nested data classes.
  • Pass the stable eventSink through UiState instead of recreating event lambdas in state().

3. ViewModel — UI Logic and State Producer

Responsibility: Own UI logic and produce UiState.

The ViewModel:

  • Coordinates domain logic.
  • Transforms data into UI-ready state.
  • Exposes a single state() entry point.
kotlin
1@Inject
2@ContributesIntoMap(AppScope::class, binding<ViewModel>())
3@ViewModelKey(ProfileViewModel::class)
4class ProfileViewModel : ViewModel(), Mvvm<ProfileUiEvent, ProfileUiState> {
5
6 private val eventSink = createEventSink(::handleEvent)
7 private val sectionsFlow = mutableState(persistentListOf<Section>()) { createSections() }
8
9 @Composable
10 override fun state(): ProfileUiState {
11 val currentSections by sectionsFlow.collectAsStateWithLifecycle()
12 return ProfileUiState(
13 sections = currentSections,
14 eventSink = eventSink,
15 )
16 }
17
18 private fun handleEvent(event: ProfileUiEvent) {
19 when (event) {
20 ProfileUiEvent.PerformLogout -> handleLogout()
21 }
22 }
23
24 private fun handleLogout() {
25 /* domain coordination */
26 }
27
28 private suspend fun createSections(): ImmutableList<Section> {
29 /* data preparation */
30 }
31}

Why state() is composable

state() is marked @Composable so it can:

  1. Participate in Compose snapshots.
  2. Automatically recompose when state changes.
  3. Expose stable references to the UI.

When to use assisted injection

The default path is a plain @Inject ViewModel created with metroViewModel(). Switch to @AssistedInject only when the ViewModel needs runtime-provided arguments that the graph cannot supply on its own, such as route parameters, IDs, or navigation payloads. In that case, use assistedMetroViewModel() at the call site.


4. Screen — Pure UI Layer

Responsibility: Render UI only.

The Screen:

  • Contains no logic.
  • Holds no state.
  • Forwards user interactions via callbacks.
kotlin
1@Composable
2fun ProfileScreen(
3 sections: ImmutableList<Section>,
4 onLogoutClicked: () -> Unit,
5) {
6 /* UI layout */
7}

Guidelines:

  • Keep screens stateless.
  • Never call ViewModel directly.
  • Treat callbacks as event emitters only.

5. Route — Wiring State to UI

Responsibility: Bind ViewModel state to the Screen.

The Route:

  • Pulls state from the ViewModel.
  • Extracts stable references.
  • Connects UI callbacks to UiEvents.
kotlin
1@Composable
2fun ProfileRoute(viewModel: ProfileViewModel) {
3 val state = viewModel.state()
4 val eventSink = state.eventSink
5 ProfileScreen(
6 sections = state.sections,
7 onLogoutClicked = {
8 eventSink(ProfileUiEvent.PerformLogout)
9 },
10 )
11}

💡 State provided by the ViewModel may change frequently. By extracting eventSink outside callbacks you keep the wiring stable and avoid redundant recompositions.

Why this layer exists

Separating the Route:

  • Keeps screens pure.
  • Prevents accidental recompositions.
  • Centralizes wiring logic.

6. UiModule — Dependency Injection and Navigation

Responsibility: Register the screen in the navigation graph.

The UiModule:

  • Contributes navigation entries.
  • Wires ViewModel factories.
  • Keeps navigation setup out of UI code.
kotlin
1@ContributesTo(UiScope::class)
2interface ProfileUiModule {
3
4 @Provides
5 @IntoSet
6 fun provideProfileNavGraphEntry(): NavGraphEntry = NavGraphEntry {
7 composable<AppNavRoutes.Profile> {
8 ProfileRoute(metroViewModel())
9 }
10 composable<AppNavRoutes.EditProfile> {
11 EditProfileRoute(metroViewModel())
12 }
13 }
14}

Guidelines:

  • Use unique provide... function names.
  • Use metroViewModel() for the normal case where the ViewModel is created entirely from graph-provided dependencies.
  • Use assistedMetroViewModel() only when the ViewModel needs runtime args from navigation or route parameters.

Mental Model

Think of a screen as a pipeline:

User Action → UiEvent → ViewModel → UiState → Screen

Each layer has:

  • One responsibility
  • One direction of data flow
  • No hidden coupling

Further Reference

For a more advanced example that:

  • Combines multiple flows
  • Reflects loading and error states
  • Demonstrates complex state coordination

See PlaygroundViewModel.